package org.io4j.filesystem;

import java.io.*;
import java.nio.channels.FileChannel;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import org.io4j.data.*;
import org.io4j.util.Argument;

/**
 * Represents an existing file in the file system. Use {@link Path} to represent
 * non-existing file locations.
 * 
 * @see Path
 * @see java.io.File
 */
public class File extends FileSystemObject implements ByteSource, TextSource, ByteSink, TextSink,
		Comparable<File> {

	private static final int BUFFER_SIZE = 102400;

	private static final String LINE_SEPARATOR = System.getProperty("line.separator");

	/**
	 * Constructs a new {@code File} from a {@link Path} location of an
	 * <em>existing</em> file.
	 * <p>
	 * To physically create a new file in the file system from non-existing path
	 * use:
	 * 
	 * <pre>
	 * File file = File.create(Path path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing file location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing directory
	 * @see Path
	 */
	public File(Path path) {
		super(path);
		Argument.is(path.exists(), "File '" + path
				+ "' doesn't exist. Use 'File.create()' instead to create it.");
		checkIfFilePath(path);
	}

	private static void checkIfFilePath(Path path) {
		Argument.isNot(path.isDirectory(), "'" + path
				+ "' is an existing directory. Use 'new Directory()' instead.");
	}

	/**
	 * Constructs a new {@code File} from a {@link java.io.File} location of an
	 * <em>existing</em> file.
	 * <p>
	 * To physically create a new file in the file system from non-existing path
	 * use:
	 * 
	 * <pre>
	 * File file = File.create(java.io.File path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing file location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing directory
	 * @see Path
	 */
	public File(java.io.File path) {
		this(new Path(path));
	}

	/**
	 * Constructs a new {@code File} from a {@code String} location of an
	 * <em>existing</em> file.
	 * <p>
	 * To physically create a new file in the file system from non-existing path
	 * use:
	 * 
	 * <pre>
	 * File file = File.create(String path);
	 * </pre>
	 * 
	 * @param path
	 *           An existing file location in the file system
	 * @throws IllegalArgumentException
	 *            if path is {@code null} or empty
	 * @throws IllegalArgumentException
	 *            if path doesn't exist or denotes an existing directory
	 * @see Path
	 */
	public File(String path) {
		this(new Path(path));
	}

	/**
	 * Creates a new file in the file system in location determined by the
	 * {@code Path}. If a file already exist in a given location, returns a
	 * {@code File} instance without changing an existing file.
	 * <p>
	 * It also creates any non-existing parent directories in a given
	 * {@code Path}.
	 * 
	 * @param path
	 *           Path in the file system where the new file will be placed
	 * @return Created or existing file
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing directory
	 * @throws FileSystemException
	 *            if new file or non-existing parent directory could not be
	 *            created
	 * @see File#File(Path)
	 * @see Directory#create(Path)
	 */
	public static File create(Path path) {
		Argument.isNotNull(path, "Path is null");
		Argument.isNot(path.isEmpty(), "Path is empty");
		if (path.exists()) {
			checkIfFilePath(path);
			return new File(path);
		}

		if (path.hasParent()) {
			ensureDirectoryExists(path.getParent());
		}
		String message = String.format("File %s could not be created", path);
		try {
			if (!path.toFile().createNewFile()) {
				throw new FileSystemException(message);
			}
		} catch (IOException exception) {
			throw new FileSystemException(message, exception);
		}
		return new File(path);
	}

	/**
	 * Creates a new file in the file system in location determined by the
	 * {@code java.io.File} path. If a file already exist in a given location,
	 * returns a {@code File} instance without changing an existing file.
	 * <p>
	 * It also creates any non-existing parent directories in a given path.
	 * 
	 * @param path
	 *           Path in the file system where the new file will be placed
	 * @return Created or existing file
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing directory
	 * @throws FileSystemException
	 *            if new file or non-existing parent directory could not be
	 *            created
	 * @see File#File(java.io.File)
	 * @see Directory#create(java.io.File)
	 */
	public static File create(java.io.File path) {
		Argument.isNotNull(path, "Path is null");
		Argument.isNot(path.getPath().isEmpty(), "Path is empty");
		return create(new Path(path));
	}

	/**
	 * Creates a new file in the file system in location determined by the
	 * {@code String} path. If a file already exist in a given location, returns
	 * a {@code File} instance without changing an existing file.
	 * <p>
	 * It also creates any non-existing parent directories in a given path.
	 * 
	 * @param path
	 *           Path in the file system where the new file will be placed
	 * @return Created or existing file
	 * @throws IllegalArgumentException
	 *            if path is {@code null}, empty or denotes an existing directory
	 * @throws FileSystemException
	 *            if new file or non-existing parent directory could not be
	 *            created
	 * @see File#File(String)
	 * @see Directory#create(String)
	 */
	public static File create(String path) {
		Argument.isNotEmpty(path, "Path is null or empty");
		return create(new Path(path));
	}

	/**
	 * Renames this file by moving it into a location denoted by a renamed path.
	 * <p>
	 * Note that the original file won't exist after this operation.
	 * 
	 * @param name
	 *           New name for the file
	 * @return Renamed file
	 * @throws IllegalArgumentException
	 *            if name is {@code null}, empty or new name is the same as the
	 *            original one
	 * @throws FileSystemException
	 *            if the file could not be renamed
	 * @see File#move(Path)
	 * @see Path#rename(String)
	 * @see java.io.File#renameTo(java.io.File)
	 */
	@Override
	public File rename(String name) {
		Argument.isNotEmpty(name, "Name is null or empty");
		return (File) super.rename(name);
	}

	/**
	 * Moves this file to a destination location in the file system determined by
	 * the destination {@code Path}.
	 * <p>
	 * Note that the original {@code File} won't exist after this operation
	 * (unless new location is the same as the original one). Use a returned
	 * {@code File} to reference the file after it has been moved to a new
	 * location.
	 * 
	 * @param destinationPath
	 *           A location in the file system where the file is to be moved to
	 * @return A {@code File} that has been moved
	 * @throws IllegalArgumentException
	 *            if destination path is {@code null}
	 * @throws FileSystemException
	 *            if the file could not be moved
	 * @see java.io.File#renameTo(java.io.File)
	 */
	public File move(Path destinationPath) {
		Argument.isNotNull(destinationPath, "Destination path is null");
		if (destinationPathEqualsCurrent(destinationPath)) {
			return this;
		}
		doMove(destinationPath);
		return new File(destinationPath);
	}

	/**
	 * Moves this file into within the specified {@code Directory}.
	 * <p>
	 * Note that the original {@code File} won't exist after this operation
	 * (unless new location is the same as the original one). Use a returned
	 * {@code File} to reference the file after it has been moved to a new
	 * location.
	 * 
	 * @param destinationDirectory
	 *           A {@code Directory} to move the file into
	 * @return A {@code File} that has been moved
	 * @throws IllegalArgumentException
	 *            if destination directory is {@code null}
	 * @throws FileSystemException
	 *            if the file could not be moved
	 */
	@Override
	public File move(Directory destinationDirectory) {
		Argument.isNotNull(destinationDirectory, "Destination directory is null");
		return (File) super.move(destinationDirectory);
	}

	/**
	 * Copies this file (source file) into a destination location determined by
	 * the {@code Path} (destination path) in the file system. If a file denoted
	 * by the destination path already exists in its contents will be replaced.
	 * 
	 * @param destinationPath
	 *           A location in the file system where the file is to be copied to
	 * @return A {@code File} that is a copy of the source file
	 * @throws IllegalArgumentException
	 *            if destination path is {@code null} or it denotes an existing
	 *            directory
	 * @throws FileSystemException
	 *            if destination file could not be created
	 * @throws DataTransferException
	 *            if transferring the contents failed
	 * @see Directory#copy(Directory)
	 */
	public File copy(Path destinationPath) {
		Argument.isNotNull(destinationPath, "Destination path is null");
		checkIfDestinationIsAFile(destinationPath);
		if (destinationPathEqualsCurrent(destinationPath)) {
			return this;
		}

		File destinationFile = File.create(destinationPath);
		copyContents(this, destinationFile);
		return destinationFile;
	}

	private static void checkIfDestinationIsAFile(Path destinationPath) {
		if (destinationPath.isDirectory()) {
			throw new IllegalArgumentException("Destination path is not a file");
		}
	}

	private static void copyContents(File source, File destination) {
		Streams.transferAndClose().usingBufferSize(BUFFER_SIZE).from(source.openInputStream())
				.to(destination.openOutputStream());

		if (source.getSize() != destination.getSize()) {
			throw new DataTransferException(String.format(
					"Failed to copy full contents from '%s' to '%s'", source, destination));
		}
	}

	/**
	 * Copies file into within a directory. If a file with the same name already
	 * exists in the destination directory its contents will be replaced.
	 * 
	 * @param destinationDirectory
	 *           Directory to copy file into
	 * @return {@code File} that has been created or overwritten in the
	 *         destination directory
	 * @throws IllegalArgumentException
	 *            if destination directory is {@code null}
	 * @throws FileSystemException
	 *            if destination file could not be created
	 * @throws DataTransferException
	 *            if transferring the contents failed
	 */
	public File copy(Directory destinationDirectory) {
		Argument.isNotNull(destinationDirectory, "Destination directory is null");
		return copy(new Path(destinationDirectory.getPath(), getName()));
	}

	/**
	 * Deletes this file from the file system.
	 * 
	 * @return {@code true} if file has been deleted successfully, {@code false}
	 *         otherwise
	 */
	public boolean delete() {
		return toFile().delete();
	}

	/**
	 * Reads the contents of the file into a byte array. The file is closed after
	 * the operation.
	 * 
	 * @return Byte array containing file contents
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public byte[] readBytes() {
		ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
		Streams.transferAndClose().from(openInputStream()).to(outputStream);
		return outputStream.toByteArray();
	}

	/**
	 * Writes a byte array to this file replacing its current contents. The file
	 * is closed after the operation.
	 * 
	 * @param data
	 *           Byte array to write to the file
	 * @throws IllegalArgumentException
	 *            if data is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public void writeBytes(byte[] data) {
		Argument.isNotNull(data, "Data is null");
		Streams.transferAndClose().from(new ByteArrayInputStream(data)).to(openOutputStream());
	}

	/**
	 * Reads lines from this file and returns them in the {@code List} using
	 * platform's default character encoding. The file is closed after the
	 * operation.
	 * 
	 * @return {@code List} of {@code String}s containing all lines in the file
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public List<String> readLines() {
		return readLines(null);
	}

	/**
	 * Reads lines from this file and returns them in the {@code List} using
	 * character set provided. The file is closed after the operation.
	 * 
	 * @param charset
	 *           Character set to use
	 * @return {@code List} of {@code String}s containing all lines in the file
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public List<String> readLines(Charset charset) {
		List<String> lines = new ArrayList<String>();
		BufferedReader reader = null;
		try {
			reader = new BufferedReader(createReader(charset));
			String line = reader.readLine();
			while (line != null) {
				lines.add(line);
				line = reader.readLine();
			}
		} catch (IOException e) {
			throw new DataTransferException(String.format("Reading lines from '%s' failed", this), e);
		} finally {
			Streams.close(reader);
		}
		return lines;
	}

	private Reader createReader(Charset charset) {
		if (charset != null) {
			return new InputStreamReader(openInputStream(), charset);
		}
		return new InputStreamReader(openInputStream());
	}

	/**
	 * Writes {@code String} representation of each element in a collection line
	 * by line using platforms's default character encoding. The file is closed
	 * after the operation.
	 * 
	 * @param lines
	 *           A collection of lines to write; {@code null} elements produce
	 *           blank lines
	 * @throws IllegalArgumentException
	 *            if collection of lines is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public void writeLines(Collection<?> lines) {
		Argument.isNotNull(lines, "Collection of lines is null");
		writeLines(lines, null);
	}

	/**
	 * Writes {@code String} representation of each element in a collection line
	 * by line using character set provided. The file is closed after the
	 * operation.
	 * 
	 * @param lines
	 *           A collection of lines to write; {@code null} elements produce
	 *           blank lines
	 * @param charset
	 *           Character set to use
	 * @throws IllegalArgumentException
	 *            if collection of lines is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public void writeLines(Collection<?> lines, Charset charset) {
		Argument.isNotNull(lines, "Collection of lines is null");
		if (lines.isEmpty()) {
			return;
		}

		BufferedWriter writer = null;
		try {
			writer = new BufferedWriter(createWriter(charset));
			for (Object line : lines) {
				if (line != null) {
					writer.write(line.toString());
				}
				writer.write(LINE_SEPARATOR);
			}
		} catch (IOException e) {
			throw new DataTransferException(String.format("Writing lines to '%s' failed", this), e);
		} finally {
			Streams.close(writer);
		}
	}

	private Writer createWriter(Charset charset) {
		if (charset != null) {
			return new OutputStreamWriter(openOutputStream(), charset);
		}
		return new OutputStreamWriter(openOutputStream());
	}

	/**
	 * Reads contents of this file and returns them as {@code String} using
	 * platform's default character set. The file is closed after the operation.
	 * 
	 * @return Contents of the file
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public String readText() {
		return readText(null);
	}

	/**
	 * Reads contents of this file and returns them as {@code String} using
	 * character set provided. The file is closed after the operation.
	 * 
	 * @param charset
	 *           Character set to use
	 * @return Contents of the file
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public String readText(Charset charset) {
		StringBuilder stringBuilder = new StringBuilder();
		BufferedReader reader = null;
		char[] buffer = new char[BUFFER_SIZE];
		int numberOfCharactersRead = 0;
		try {
			reader = new BufferedReader(createReader(charset));
			while (-1 != (numberOfCharactersRead = reader.read(buffer))) {
				stringBuilder.append(buffer, 0, numberOfCharactersRead);
			}
		} catch (IOException e) {
			throw new DataTransferException(String.format("Reading text from '%s' failed", this), e);
		} finally {
			Streams.close(reader);
		}
		return stringBuilder.toString();
	}

	/**
	 * Writes text into the file using platforms's default character set. The
	 * file is closed after the operation.
	 * 
	 * @param text
	 *           Text to write
	 * @throws IllegalArgumentException
	 *            if text is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public void writeText(String text) {
		Argument.isNotNull(text, "Text is null");
		writeText(text, null);
	}

	/**
	 * Writes text into the file using character set provided. The file is closed
	 * after the operation.
	 * 
	 * @param text
	 *           Text to write
	 * @param charset
	 *           Character set to use
	 * @throws IllegalArgumentException
	 *            if text is {@code null}
	 * @throws DataTransferException
	 *            if an error occurred during the operation
	 */
	public void writeText(String text, Charset charset) {
		Argument.isNotNull(text, "Text is null");

		BufferedWriter writer = null;
		try {
			writer = new BufferedWriter(createWriter(charset));
			writer.write(text);
		} catch (IOException e) {
			throw new DataTransferException(String.format("Writing text to '%s' failed", this), e);
		} finally {
			Streams.close(writer);
		}
	}

	/**
	 * Returns {@code FileInputStream} for reading from this file.
	 * 
	 * @throws FileSystemException
	 *            if file cannot be opened for reading
	 */
	public InputStream openInputStream() {
		if (!isReadable()) {
			throw new FileSystemException(String.format("File %s cannot be read", toString()));
		}

		InputStream inputStream = null;
		try {
			inputStream = new FileInputStream(toFile());
		} catch (FileNotFoundException e) {
			throw new FileSystemException(String.format("File %s could not be opened", toString()), e);
		}
		return inputStream;
	}

	/**
	 * Returns {@code FileOutputStream} for writing to this file.
	 * 
	 * @throws FileSystemException
	 *            if file cannot be opened for writing
	 */
	public OutputStream openOutputStream() {
		if (!isWritable()) {
			throw new FileSystemException(String.format("File %s cannot be read", toString()));
		}

		OutputStream outputStream = null;
		try {
			outputStream = new FileOutputStream(toFile());
		} catch (FileNotFoundException e) {
			throw new FileSystemException(String.format("File %s could not be opened", toString()), e);
		}
		return outputStream;
	}

	/**
	 * Returns a {@code FileChannel} for either reading from or writing to this
	 * file.
	 * 
	 * @throws FileSystemException
	 *            if file cannot be opened
	 */
	public FileChannel openChannel() {
		RandomAccessFile randomAccessStream = null;
		try {
			randomAccessStream = new RandomAccessFile(toFile(), "rw");
		} catch (FileNotFoundException e) {
			throw new FileSystemException(String.format("File %s could not be opened", toString()), e);
		}
		return randomAccessStream.getChannel();
	}

	/**
	 * Returns a {@code FileChannel} for reading from this file.
	 * 
	 * @throws FileSystemException
	 *            if file cannot be opened
	 */
	public FileChannel openChannelForReading() {
		return ((FileInputStream) openInputStream()).getChannel();
	}

	/**
	 * Returns a {@code FileChannel} for writing to this file.
	 * 
	 * @throws FileSystemException
	 *            if file cannot be opened
	 */
	public FileChannel openChannelForWriting() {
		return ((FileOutputStream) openOutputStream()).getChannel();
	}

	/**
	 * Returns {@code Directory} that contains this file.
	 */
	public Directory getDirectory() {
		return new Directory(getPath().getParent());
	}

	/**
	 * Compares this file with the other file by comparing their paths
	 * lexicographically. Paths are compared case-sensitively on UNIX operating
	 * systems but case-insensitively on Microsoft Windows operating systems.
	 * 
	 * @param otherFile
	 *           The file to be compared with
	 * @return zero if this file's path is equal to the other file's path, a
	 *         value less than zero if this file's path is lexicographically less
	 *         than the other file's path or a value greater than zero if this
	 *         file's path is lexicographically greater than the other file's
	 *         path
	 * @throws IllegalArgumentException
	 *            if other file is {@code null}
	 */
	public int compareTo(File otherFile) {
		Argument.isNotNull(otherFile, "Other file is null");
		return super.compareTo(otherFile);
	}
}
